iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI Engineering

不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天系列 第 13

Day 13 - 跑到 90 tok/s,為什麼第一個答案字還要等 8 秒?

  • 分享至 

  • xImage
  •  

先看一組我自己量到的數字。gemma4:26b 跑在 M4 Max 上,Ollama 回報的 decode 速度是 90.2 tok/s,在地端算快。同一次請求,我問它「用一句話說明 RAG 是什麼」,第一個答案字出現在 8.33 秒

這兩個數字不衝突,粗算一次就知道量級。8.33 秒扣掉熱機的 load 與 prefill,有 8.0 秒真的在生成,90 tok/s 乘下去是七百多個 token——**而那七百多個幾乎全進了思考,沒有一個是答案。**它沒騙你,只是沒告訴你這 90 個 token 是在想還是在講。

Day 02 我埋過一句:「四個引擎沒辦法同場,Day 13 要花一整篇講。」Day 08 又留了一筆:「Ollama 那一口徑挪到 Day 13。」今天兩筆一起結——而要講清楚為什麼會有那 8 秒,得先示範一次:大家最想要的那張 tok/s 對照表,做起來有多難。

同一顆 Qwen3.8-27B,在 Mac、在 4070 Ti、在 DGX Spark 上,各跑幾個 token 每秒?

我試著把它做出來,第一格就填不了。

第一格:27B 塞不進 12GB 卡

Qwen3.8-27B 的 UD-Q4_K_M 是 16.46 GB = 15.33 GiB。T2 那張 4070 Ti,CUDA 認到 11.99 GiB —— 光權重就差 3.34 GiB,KV cache、compute buffer 與 runtime 自己要用的那些都還沒開始算。

嚴格說不是「跑不了」。llama.cpp 可以只 offload 一部分層,其餘留在 CPU 與系統記憶體,這顆還是會動。但那條路徑已經不是我要比的東西:一旦有層落在 CPU,量到的是 PCIe 與 DDR5 的速度,跟另外兩台權重全程待在 GPU 上完全不是同一件事。

所以第一格不是「慢」,是沒辦法 full offload——Day 09 那條容量線,塞得下才輪到比快慢。

那就降尺寸。7B 級三台都塞得下——這時撞上第二道牆。

第二格:三台跑的根本不是同一份東西

問題不在「哪個引擎比較快」,在於沒有一個引擎三台都是主力:

T1 M4 Max T2 4070 Ti DGX Spark
llama.cpp ✓ Metal ✓ CUDA ✓ CUDA on ARM
vLLM / vLLM-Metal △ 裝得起來,但不對等
MLX / mlx-lm △ 有 mlx[cuda],但不是這台的主力 △ 同左

這張表兩年前可以寫成幾個乾脆的叉,現在不行了。MLX 官方已經有 CUDA backend(pip install mlx[cuda]);vLLM 也早有 Apple Silicon 的 plugin,Day 03 講過它裝得起來——只是 Docker 那份同機對照裡,同一顆 1B llama.cpp 快它 1.2–1.3 倍,波動還大得多。

能裝,跟能在這台發揮,是兩件事,而我要比的是後者。卡住的也不是格式讀不讀得進去——MLX 讀得了 GGUF 與 safetensors,vLLM-Metal 現在也吃 GGUF。卡住的是量化檔:Mac 上最快的 MLX/NVFP4、CUDA 上常見的 AWQ 或 FP8、llama.cpp 的 GGUF,通常不是同一份。想比「各平台各自最好的表現」,等於同時放掉權重、量化與 runtime 三個變因。

真要對齊,只有一個交集:llama.cpp 的 GGUF,三台都跑得動。代價是誰都沒發揮最好的自己——Mac 沒用 MLX、Spark 沒用 vLLM。這就是那句「沒辦法同場」的意思:不是誰比較快,是根本沒有同一條起跑線。

能做出來的最誠實版本

共同基準線是 llama-bench、同一顆模型檔(跟官方 Spark 跑分同一個 repo、同一個檔,大小逐一比對過)。T1 與 T2 是我自己跑的,build b10488,照 Day 08 立的那組釘子:-ngl 99 -fa off

Spark 那欄是別人跑的:llama.cpp 官方在真機上的跑分檔,build 11fb327bf (7941),flag 是 ngl=99, n_ubatch=2048, fa=1, mmap=0, dio=1。⚠️ build 不同,-fa 也不同——我 off,官方 on。 Day 08 就量過這個開關能在跑序效應下造出 ±16% 的假象,而 FA 對 prefill 的影響又比對 decode 大。所以 Spark 那欄是近似對照,不是嚴格 A/B,prefill 那一格尤其只能看量級。

https://ithelp.ithome.com.tw/upload/images/20260824/20183550VvqtubXqak.png
圖 1:4070 Ti 的 prefill 長到快出框,decode 卻跟 M4 Max 一樣。

頻寬 pp2048 tg32
Qwen2.5-Coder-7B Q8_0
T1 M4 Max(實測) 546 819.74 59.64
T2 4070 Ti(實測) 504 6555.93 59.27
DGX Spark(官方跑分) 273 2250.28 29.43
Gemma-3-4B Q4_0
T1 M4 Max(實測) 546 1849.29 131.82
T2 4070 Ti(實測) 504 11120.91 146.21
DGX Spark(官方跑分) 273 5948.74 81.05

7B 那組:prefill 三台差到八倍,decode 卻是兩台打平、一台落後一半。4B 那組沒這麼乾淨——prefill 只差六倍,decode 也不是打平,4070 Ti 反而快了 11%。下面那把尺先用 7B 這組講,因為它最極端。

prefill 那欄比較好解釋,它更容易進到 compute-bound 那一側:矩陣運算量隨 prompt 長度成長,GPU 的算力、kernel 實作與量化路徑都會被放大。同一顆 7B,4070 Ti 是 M4 Max 的八倍。

真正有意思的是 decode 那欄。

頻寬差 8%,速度卻一模一樣

M4 Max 的 546 GB/s 比 4070 Ti 的 504 多了 8.3%,但 7B 的 tg 是 59.64 對 59.27,差 0.6%。

拿 Day 10 那把尺量一次。兌現率是實測 tg 除以「頻寬 ÷ 每 step 要讀的權重」,再乘回帳面頻寬,就得到權重讀取等效:

頻寬 兌現率(7B) 等效
T1 M4 Max 546 82.1% 448 GB/s
T2 4070 Ti 504 88.4% 446 GB/s
DGX Spark 273 81.1% 221 GB/s

先說清楚這張表不是什麼。同一顆模型下,等效就等於 tg × 每 step 權重它是把 tok/s 換成比較直覺的單位,不是第二次獨立量測。所以「等效比值等於 tg 比值」是恆等式,拿它當佐證等於自己證自己。要真的驗證這條模型,得換好幾個尺寸去擬合 Day 10 那條 t = W/B + t_fixed,或者直接量記憶體頻寬。

有資訊的是兌現率那一欄。三條硬體 × runtime 路徑(Metal、x86 CUDA、ARM CUDA),dense 7B 的兌現率落在 81% 到 88% 這條窄帶裡。這條帶 Day 10 的 T2(80%)、Day 12(80.3%)各自出現過一次,今天第一次三台同時對上。

M4 Max 帳面多出來的 8.3%,正好被它低了 6.3 個百分點的兌現率吃掉,兩者相乘剩 0.6%。而 Spark 的 decode 是前兩台的一半,成因很直白:頻寬正好一半(273 對 546),兌現率又跟 M4 Max 幾乎相同(81.1% 對 82.1%)。

⚠️ 這兩組裡 Metal 的兌現率都低於 CUDA(7B 差 6.3、4B 差 10.5 個百分點),方向一致,但只有兩顆模型、又跨了平台整包,成因得留給受控實驗。

換成 4B 那顆,三台的兌現率一起掉到 51.9% / 62.4% / 63.8%。方向符合 Day 10 的固定成本模型:每 step 的工作量變小,kernel launch、取樣、排程這些固定開銷佔比就上去。不過這裡同時換了三件事——架構(Qwen2.5 → Gemma 3)、尺寸(7B → 4B)、量化(Q8_0 → Q4_0),所以只能說方向一致,不能把落差全記到尺寸頭上。

但這兩顆已經是上一代了

上面那張表能做出來,是因為我遷就了官方跑分檔的清單——它只跑了 Qwen2.5、Gemma 3 那一輩——Qwen2.5-Coder 到今天已經快兩年。你現在裝在機器上的不是這些。

換成你真的在用的模型,表就只剩兩欄:Spark 那格沒有人跑過。

這是「沒辦法同場」的第三種形式:不只引擎不同、格式不同,連時間都不同。公開跑分通常落後你手上的模型一到兩代,而你想比的那顆,多半沒有人替你比過。

到這裡,那張三機表已經做不下去了。我本來以為,只要把模型、量化、參數跟引擎全部對齊,這張表就算公平了。後來才發現,就算真的公平,它回答的仍然不是我按下 enter 之後最在意的那件事。

引擎報的 t/s,不是你感受到的速度

llama-benchtg 量的是純解碼:模型已經載好、prompt 已經算完,穩態下每秒吐幾個 token。你按下 enter 到看完答案,中間還有模型載入、prompt 處理,以及今天的重點:模型自己先想一輪。

換 Ollama 量之前,得先更正 Day 02 的一句話。當時我寫「Ollama 底層不是 vLLM,是 llama.cpp」——這句現在只對一半。2026 年 3 月 30 日的 0.19 在 Apple Silicon 加了 MLX engine;6 月 5 日的 0.30 又把 GGUF/llama.cpp 那條路補回來並加強,官方自己的說法是「augments Ollama's MLX engine」。現在是混合的,同一個 API 底下不保證是同一個 runner。

這件事不用猜,ps 就看得到。三顆模型同時掛著的時候:

ollama runner --mlx-engine --model qwen3.8:27b-mlx --port 54806
llama-server --model .../sha256-3d0b790534fe… -c 40960 …
llama-server --model .../sha256-7121486771cb… -c 262144 …

對回 ollama show,兩邊完全吻合:

模型 architecture quantization runner
qwen3.8:27b-mlx qwen3_5 nvfp4 --mlx-engine
gemma4:26b gemma4 Q4_K_M llama-server
qwen3.6:latest qwen35moe Q4_K_M llama-server

同一個 ollama run,一顆走 MLX、兩顆走 llama.cpp。「Ollama 的 t/s」這六個字,連底下是不是同一個引擎都不保證。

這反而把今天的主題坐實了。Day 08 那張表量的是純引擎吞吐,Day 02 那張量的是 Ollama 的端到端,口徑不同不能混填——現在還得再加一句:同一個口徑裡也可能混著兩個引擎。

換成端到端口徑再量一次,同一台 M4 Max、同一顆 qwen3.8:27b-mlx,同一句提問問三次(thinking 開著,等一下就知道為什麼要強調):

https://ithelp.ithome.com.tw/upload/images/20260824/20183550W0dl37DWyE.png
圖 2:紅色的思考,比綠色的答案還長。

看到第一個答案字 端到端 load prefill decode
第一問(剛卸載完) 3.16s 4.31s 0.954 0.191 3.160
第二問 1.98s 3.15s 0.045 0.189 2.916
第三問 2.25s 3.25s 0.045 0.000 3.205

load + prefill 只花 1.15 / 0.23 / 0.05 秒,第一個答案字卻要等 3.16 / 1.98 / 2.25 秒。中間差的那兩秒模型都在想,而思考的 token 也是 token,算在 decode 那一欄裡。(第一問那格 3.16 跟它自己的 decode 3.160 撞在一起是巧合——那次答案剛好講了 1.15 秒,跟 load + prefill 一樣長。)

順帶把口徑釘一下:**這一欄不是 Day 02 那個 TTFT。**思考的 token 早就開始生了,我量的是第一個 message.content 非空的時間——第一個「答案」字。對會思考的模型,這兩個口徑差的就是整段思考。

三次的 decode 都在三秒上下,端到端卻從 4.31 秒掉到 3.15 秒。變的是前面那兩段:第一次多花的 0.95 秒全記在 load_duration,第三次連 prefill 都歸零——prefix cache 全中,那是 Day 18 的主題。

⚠️ 這裡的「冷」還不夠冷。ollama stop 卸掉的是 model runner、放掉它佔住的記憶體,模型檔本身還躺在 macOS 的 page cache 裡,所以只花 0.954 秒。真正沒讀過的第一次會慢得多——我第一輪量到的是 6.07 秒。Day 02 寫過「冷啟可達數十秒」但只是社群回報的量級,這筆帳 Day 17 單獨算。

真正吃掉時間的是它先想了一輪

前面那張表還是熱的、還是短問題,而且只有一顆模型。把 thinking 當開關,換三顆模型再問一次:

同一句「用一句話說明 RAG 是什麼」、temperature 0seed 42不設 num_predict(Ollama 預設不限,一般人也不會去改)。

https://ithelp.ithome.com.tw/upload/images/20260824/20183550plixc3ochJ.png
圖 3:綠色那截才是答案,兩顆 MoE 幾乎看不見。

模型 thinking 開始講 端到端 想幾字元 答幾字元
gemma4:26b 8.33s 8.65s 2332 40
0.28s 0.82s 0 78
qwen3.6 8.90s 9.45s 2437 68
0.21s 0.66s 0 61
qwen3.8-mlx 2.08s 3.35s 185 87
0.11s 0.99s 0 68

開場那 8.33 秒,答案在這裡。兩顆 MoE 想了兩千三、兩千四百個字元,才講出四十到七十個字元(是輸出字串長度,不是 token 數;gemma4 那 2,332 字元約對應七百多個 token)。「開始看到答案」被推遲 18 到 42 倍,端到端差 3.4 到 14.3 倍。全部都是模型自己講完(stop),沒有任何人為截斷。

而 decode 速度呢?90.2 對 98.4、85.2 對 88.8、46.3 對 44.6——幾乎沒變。

引擎沒有變慢,拉長等待的是模型為這道一句話就能答的題做了一輪用不上的推理。thinking 對數學、程式、多步規劃是真的有用,只是這題不需要——而純引擎吞吐那張表,一個字都看不出這件事。這正是 Day 02 說的:只看那四個指標,它們會全部報給你「一切正常」。

怎麼把這個開關關掉、關掉之後答案品質掉多少,Day 16 整篇處理。今天只需要記住一件事:它不是 decode 變慢,所以單看 tok/s 永遠抓不到它。

今天的實驗需要什麼

一台機器就能重現後半段,指令是 Ollama 的 /api/chat

curl -s http://localhost:11434/api/chat -d '{
  "model":"gemma4:26b",
  "messages":[{"role":"user","content":"用一句話說明 RAG 是什麼。"}],
  "stream":true, "think":false,
  "options":{"temperature":0,"seed":42}}' --no-buffer

"think"true / false 之間切,量兩件事:第一個 content 非空的時間、以及整串跑完的時間。

⚠️ 兩個我自己踩過的坑,第一個連我原本的診斷都是錯的。

我第一版用 /api/generate,拿到 "response":""eval_count 照跳,歸因成「它不套 chat template」。回頭查官方文件才發現不是——它一樣會套 template,除非你自己指定 raw:true。真正的原因是思考的 token 進了另一個欄位:兩個 endpoint 都有 thinking/api/chatmessage.thinkingmessage.content),我只讀 response 當然是空的。兩個都能用,只讀一欄才會量到「生成空內容」的速度。

第二個坑:不要設 num_predict。我第一版設了 512,模型還在想就被砍斷,量出「永遠拿不到答案」的假象——那是我造成的,不是模型的行為。

小結

  • 三機表的第一格就填不上:27B Q4 = 15.33 GiB,12GB 卡差 3.34 GiB,只能把一部分層丟給 CPU——那已經不是同一條路徑。容量線先過,才輪到比速度。
  • 勉強對齊之後(7B Q8_0):prefill 三台差到八倍,decode 卻是 M4 Max 與 4070 Ti 打平、Spark 落後一半。dense 7B 的兌現率三台落在 81–88% 這條窄帶,帳面多出來的 8.3% 被低了 6.3 個百分點的兌現率抵銷掉。帳面頻寬只給天花板,實際 decode 還要看這條 runtime 路徑兌現得了多少。
  • 公開跑分通常落後你手上的模型一到兩代。你想比的那顆,多半沒有人替你比過——這是「沒辦法同場」最現實的一種。
  • 引擎報的 t/s 不是你感受到的速度。 打開 thinking,端到端差到 14 倍而 decode 幾乎沒變;而且「Ollama 的 t/s」連底下是不是同一個引擎都不保證。

明天,Day 14:把今天量到的 t/s 換算成「這台機器能同時服務幾個人」。Little's Law 從紙上算出它該在哪個 rate 崩,掃描表從實機量出它實際在哪裡崩——兩個數字擺在一起,公式才算被驗過。

咱們明天見。


上一篇
Day 12 - 504 + 504 為什麼不等於 1008:消費卡雙卡的通訊帳
下一篇
Day 14 - 這台機器能同時服務幾個人?Little 定律與七檔容量速查表
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言